5

前端模板的发展

模板可以说是前端开发最常接触的工具之一。将页面固定不变的内容抽出成模板,服务端返回的动态数据装填到模板中预留的坑位,最后组装成完整的页面html字符串交给浏览器去解析。

模板可以大大提升开发效率,如果没有模板开发人员怕是要手动拼写字符串。

var tpl = '<p>' + user.name + '</p>';
$('body').append(tpl);

在近些年前端发展过程中,模板也跟着变化:

1. php模板 JSP模板

早期还没有前后端分离时代,前端只是后端项目中的一个文件夹,这时期的php和java都提供了各自的模板引擎。以JSP为例:java web应用的页面通常是一个个.jsp的文件,这个文件内容是大部分的html以及一些模板自带语法,本质上是纯文本,但是既不是html也不是java。

JSP语法:index.jsp

<html>
<head><title>Hello World</title></head>
<body>
Hello World!<br/>
<%
out.println("Your IP address is " + request.getRemoteAddr());
%>
</body>
</html>

这个时期的模板引擎,往往是服务端来编译模板字符串,生成html字符串给客户端。

2. handlebar mustache通用模板

09年node发布,JavaScript也可以来实现服务端的功能,这也大大的方便了开发人员。mustache和handlebar模板的诞生方便了前端开发人员,这两个模板均使用JavaScript来实现,从此前端模板既可以在服务端运行,也可以在客户端运行,但是大多数使用场景都是js根据服务端异步获取的数据套入模板,生成新的dom插入页码。 对前端后端开发都非常有利。

mustache语法:index.mustache

<p>Username: {{user.name}}</p>
{{#if (user.gender === 2)}}
    <p>女</p>
{{/if}}

3. vue中的模板 React中的JSX

接下来到了新生代,vue中的模板写法跟之前的模板有所不同,而且功能更加强大。既可以在客户端使用也可以在服务端使用,但是使用场景上差距非常大:页面往往根据数据变化,模板生成的dom发生变化,这对于模板的性能要求很高。

vue语法:index.vue

<p>Username: {{user.name}}</p>
<template v-if="user.gender === 2">
    <p>女</p>
</div>    

模板实现的功能

无论是从JSP到vue的模板,模板在语法上越来越简便,功能越来越丰富,但是基本功能是不能少的:

  1. 变量输出(转义/不转义):出于安全考虑,模板基本默认都会将变量的字符串转义输出,当然也实现了不转义输出的功能,慎重使用。
  2. 条件判断(if else):开发中经常需要的功能。
  3. 循环变量:循环数组,生成很多重复的代码片段。
  4. 模板嵌套:有了模板嵌套,可以减少很多重复代码,并且嵌套模板集成作用域。

以上功能基本涵盖了大多数模板的基础功能,针对这些基础功能就可以探究模板如何实现的。

模板实现原理

正如标题所说的,模板本质上都是纯文本的字符串,字符串是如何操作js程序的呢?

模板用法上:

var domString = template(templateString, data);

模板引擎获得到模板字符串和模板的作用域,经过编译之后生成完整的DOM字符串。

大多数模板实现原理基本一致:

模板字符串首先通过各种手段剥离出普通字符串和模板语法字符串生成抽象语法树AST;然后针对模板语法片段进行编译,期间模板变量均去引擎输入的变量中查找;模板语法片段生成出普通html片段,与原始普通字符串进行拼接输出。

其实模板编译逻辑并没有特别复杂,至于vue这种动态绑定数据的模板有时间可以参考文末链接。

快速实现简单的模板

现在以mustache模板为例,手动实现一个实现基本功能的模板。

模板字符串模板:index.txt

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>Page Title</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" type="text/css" media="screen" href="main.css" />
  <script src="main.js"></script>
</head>
<body>
  <h1>Panda模板编译</h1>
  <h2>普通变量输出</h2>
  <p>username: {{common.username}}</p>
  <p>escape:{{common.escape}}</p>
  <h2>不转义输出</h2>
  <p>unescape:{{&common.escape}}</p>
  <h2>列表输出:</h2>
  <ul>
  {{#each list}}
    <li class="{{value}}">{{key}}</li>
  {{/each}}
  </ul>
  <h2>条件输出:</h2>
  {{#if shouldEscape}}
    <p>escape{{common.escape}}</p>
  {{else}}
    <p>unescape:{{&common.escape}}</p>
  {{/if}}
</body>
</html>

模板对应数据:

module.exports = {
  common: {
    username: 'Aus',
    escape: '<p>Aus</p>'
  },
  shouldEscape: false,
  list: [
    {key: 'a', value: 1},
    {key: 'b', value: 2},
    {key: 'c', value: 3},
    {key: 'd', value: 4}
  ]
};

模板的使用方法:

var fs = require("fs");
var tpl = fs.readFileSync('./index.txt', 'utf8');
var state = require('./test');
var Panda = require('./panda');

Panda.render(tpl, state)

然后来实现模板:

1. 正则切割字符串

模板引擎获取到模板字符串之后,通常要使用正则切割字符串,区分出那些是静态的字符串,那些是需要编译的代码块,生成抽象语法树(AST)。

// 将未处理过的字符串进行分词,形成字符组tokens
Panda.prototype.parse = function (tpl) {
  var tokens = [];
  var tplStart = 0;
  var tagStart = 0;
  var tagEnd = 0;

  while (tagStart >= 0) {
    tagStart = tpl.indexOf(openTag, tplStart);
    if (tagStart < 0) break;
    // 纯文本
    tokens.push(new Token('text', tpl.slice(tplStart, tagStart)));

    tagEnd = tpl.indexOf(closeTag, tagStart) + 2;
    if (tagEnd < 0) throw new Error('{{}}标签未闭合');
    // 细分js

    var tplValue = tpl.slice(tagStart + 2, tagEnd - 2);
    var token = this.classifyJs(tplValue);
    tokens.push(token);

    tplStart = tagEnd;
  }

  // 最后一段
  tokens.push(new Token('text', tpl.slice(tagEnd, tpl.length)));

  return this.parseJs(tokens);
};

这一步分割字符串通常使用正则来完成的,后面检索字符串会大量用到正则方法。

在这一步通常可以检查出模板标签闭合异常,并报错。

2. 模板语法的分类

生成AST之后,普通字符串不需要再管了,最后会直接输出,专注于模板语法的分类。

// 专门处理模板中的js
Panda.prototype.parseJs = function (tokens) {
  var sections = [];
  var nestedTokens = [];
  var conditionsArray = [];
  var collector = nestedTokens;
  var section;
  var currentCondition;

  for (var i = 0; i < tokens.length; i++) {
    var token = tokens[i];
    var value = token.value;
    var symbol = token.type;

    switch (symbol) {
      case '#': {
        collector.push(token);
        sections.push(token);

        if(token.action === 'each'){
          collector = token.children = [];
        } else if (token.action === 'if') {
          currentCondition = value;
          var conditionArray;
          collector = conditionArray = [];
          token.conditions = token.conditions || conditionsArray;

          conditionsArray.push({
            condition: currentCondition,
            collector: collector
          });
        }
        break;
      }
      case 'else': {
        if(sections.length === 0 || sections[sections.length - 1].action !== 'if') {
          throw new Error('else 使用错误');
        }

        currentCondition = value;
        collector = [];

        conditionsArray.push({
          condition: currentCondition,
          collector: collector
        });

        break;
      }
      case '/': {
        section = sections.pop();

        if (section && section.action !== token.value) {
          throw new Error('指令标签未闭合');
        }

        if(sections.length > 0){
          var lastSection = sections[sections.length - 1];
          if(lastSection.action === 'each'){
            collector = lastSection.chidlren;
          } else if (lastSection.action = 'if') {
            conditionsArray = [];
            collector = nestedTokens;
          }
        } else {
          collector = nestedTokens;
        }

        break;
      }
      default: {
        collector.push(token);
        break;
      }
    }
  }

  return nestedTokens;
}

上一步我们生成了AST,这个AST在这里就是一个分词token数组:

[
    Token {},
    Token {},
    Token {},
]

这个token就是每一段字符串,分别记录了token的类型,动作,子token,条件token等信息。

/**
 * token类表示每个分词的标准数据结构
 */
function Token (type, value, action, children, conditions) {
  this.type = type;
  this.value = value;

  this.action = action;
  this.children = children;
  this.conditions = conditions;
}

在这一步要将循环方法中的子token嵌套到对应的token中,以及条件渲染子token嵌套到对应token中。

这步完成之后,一个标准的带有嵌套关系的AST完成了。

3. 变量查找与赋值

现在开始根据token中的变量查找到对应的值,根据相应功能生成值得字符串。

/**
 * 解析数据结构的类
 */
function Context (data, parentContext) {
  this.data = data;
  this.cache = { '.': this.data };
  this.parent = parentContext;
}

Context.prototype.push = function (data) {
  return new Context(data, this);
}

// 根据字符串name找到真实的变量值
Context.prototype.lookup = function lookup (name) {
  name = trim(name);

  var cache = this.cache;

  var value;
  // 查询过缓存
  if (cache.hasOwnProperty(name)) {
    value = cache[name];
  } else {
    var context = this, names, index, lookupHit = false;

    while (context) {
      // user.username
      if (name.indexOf('.') > 0) {
        value = context.data;
        names = name.split('.');
        index = 0;

        while (value != null && index < names.length) {
          if (index === names.length - 1) {
            lookupHit = hasProperty(value, names[index]);
          }

          value = value[names[index++]];
        }
      } else {
        value = context.data[name];
        lookupHit = hasProperty(context.data, name);
      }

      if (lookupHit) {
        break;
      }

      context = context.parent;
    }

    cache[name] = value;
  }

  return value;
}

为了提高查找效率,采用缓存代理,每次查找到的变量存储路径方便下次快速查找。

不同于JavaScript编译器,模板引擎在查找变量的时候找不到对应变量即终止查找,返回空并不会报错。

4. 节点的条件渲染与嵌套

这里开始讲模板语法token和普通字符串token开始统一编译生成字符串,并拼接成完整的字符串。

// 根据tokens和context混合拼接字符串输出结果
Panda.prototype.renderTokens = function (tokens, context) {
  var result = '';
  var token, symbol, value;

  for (var i = 0, numTokens = tokens.length; i < numTokens; ++i) {
    value = undefined;
    token = tokens[i];
    symbol = token.type;

    if (symbol === '#') value = this.renderSection(token, context);
    else if (symbol === '&') value = this.unescapedValue(token, context);
    else if (symbol === '=') value = this.escapedValue(token, context);
    else if (symbol === 'text') value = this.rawValue(token);

    if (value !== undefined) result += value;
  }

  return result;
}

5. 绘制页面

页面字符串已经解析完成,可以直接输出:

Panda.prototype.render = function (tpl, state) {
  if (typeof tpl !== 'string') {
    return new Error('请输入字符串!');
  }

  // 解析字符串
  var tokens = this.cache[tpl] ? tokens : this.parse(tpl);
  // 解析数据结构
  var context = state instanceof Context ? state : new Context(state);
  // 渲染模板
  return this.renderTokens(tokens, context);
};

输出页面字符串被浏览器解析,就出现了页面。

以上只是简单的模板实现,并没有经过系统测试,仅供学习使用,源码传送门。成熟的模板引擎是有完整的异常处理,变量查找解析,作用域替换,优化渲染,断点调试等功能的。

总结

前端模板这块能做的东西还很多,很多框架都是集成模板的功能,配合css,js等混合编译生成解析好样式和绑定成功事件的dom。

另外实现模板的方式也有很多,本文的实现方式参考了mustache源码,模板标签内的代码被解析,但是是通过代码片段分类,变量查找的方式来执行的,将纯字符串的代码变成了被解释器执行的代码。

另外向vue这种可以实现双向绑定的模板可以抽空多看一看。

参考资料

  1. 前端模板的原理与实现
  2. Vue 模板编译原理
  3. 现一个前端模板引擎
  4. mustache
  5. [如何选择-Web-前端模板引擎

Aus0049
2.4k 声望231 粉丝

console.log(([][[]]+[])[+!![]]+([]+{})[!+[]+!![]])